cl: fix Gloas checkpoint sync with external execution clients - #22683
cl: fix Gloas checkpoint sync with external execution clients#22683domiwei wants to merge 12 commits into
Conversation
c4c3417 to
8fd2aaf
Compare
8fd2aaf to
2ffc4ae
Compare
7fa3c7e to
38844bf
Compare
1b55705 to
0b0a9d8
Compare
38844bf to
18701d4
Compare
There was a problem hiding this comment.
Pull request overview
Stabilizes Caplin’s Gloas sync when paired with an external execution client by bounding/serializing expensive validation and recovery paths, improving envelope/data-availability handling, and reducing stall/OOM risk in fork-choice, networking, and stages.
Changes:
- Introduce shared/ bounded Engine API
NewPayloadvalidation coordination (including releasing fork-choice mutex during external EL calls) and tighten payload-attestation validation to avoid redundant work. - Harden Gloas envelope fetching/validation (HTTP bounds, exact-root checks, malformed-candidate handling) and implement fair, bounded skipped-envelope recovery batching.
- Adjust runtime sequencing (PeerDAS explicit
Start) and observer aggregate verification logic.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| cmd/caplin/caplin1/run.go | Starts PeerDAS explicitly and updates constructor usage. |
| cl/spectest/consensus_tests/fork_choice.go | Updates PeerDAS constructor usage in spectests. |
| cl/phase1/stages/stage_history_download.go | Adds bounded, batched recovery for skipped envelopes with deadlines and exact-root validation. |
| cl/phase1/stages/stage_history_download_test.go | Tests fairness/partial-success behavior for batched skipped-envelope recovery. |
| cl/phase1/stages/gloas_payload_test.go | Updates/extends tests for shared payload validation coordinator and anchor validation behavior. |
| cl/phase1/stages/forward_sync.go | Routes payload validation through shared coordinator; validates via execution client when present. |
| cl/phase1/stages/clstages.go | Wires a shared payload validation coordinator into stage config (prefers forkchoice’s coordinator). |
| cl/phase1/stages/chain_tip_sync.go | Switches gating from “retry local EL” to “validate with any execution client”; shares coordinator. |
| cl/phase1/network/services/payload_attestation_service.go | Deduplicates in-flight per-validator attestation validations to limit duplicate work. |
| cl/phase1/network/services/payload_attestation_service_test.go | Adds concurrency/serialization/retry tests for attestation validation deduplication. |
| cl/phase1/network/services/execution_payload_service.go | Queues envelopes when PeerDAS column data is unavailable; improves pending processing semantics. |
| cl/phase1/network/services/execution_payload_service_test.go | Adds tests for pending envelope behavior when data availability is delayed/fails. |
| cl/phase1/network/services/aggregate_and_proof_service.go | Introduces shouldVerifyAggregate to handle observer behavior consistently. |
| cl/phase1/network/services/aggregate_and_proof_service_test.go | Tests observer aggregate verification gating. |
| cl/phase1/network/beacon_downloader.go | Filters incomplete blocks before processing and validates/fetches missing envelopes with bounded HTTP reads. |
| cl/phase1/network/backward_beacon_downloader.go | Improves Gloas FULL/EMPTY inference via canonical lineage lookahead; bounds skipped-full tracking and HTTP reads; validates envelopes against blocks. |
| cl/phase1/network/backward_beacon_downloader_test.go | Adds extensive coverage for lookahead, envelope validation/fallback, bounded reads, and skipped-full memory budget. |
| cl/phase1/forkchoice/payload_attestation_validation.go | Adds cached, singleflight validation contexts for payload attestations to avoid repeated full-state work. |
| cl/phase1/forkchoice/payload_attestation_validation_test.go | Tests caching/singleflight/bounds and signature/position validation. |
| cl/phase1/forkchoice/on_payload_attestation_message.go | Refactors payload attestation verification to use the new cached validation context. |
| cl/phase1/forkchoice/on_execution_payload.go | Releases fork-choice mutex around NewPayload, coalesces/bounds validations, and refreshes state after unlock. |
| cl/phase1/forkchoice/on_execution_payload_test.go | Adds tests for mutex release, coalescing keys, bounded concurrency, cancellation, and panic recovery. |
| cl/phase1/forkchoice/gloas_weight_tree.go | Reuses spare slice capacity safely (clearing newly exposed entries). |
| cl/phase1/forkchoice/gloas_weight_tree_test.go | Tests spare-capacity reuse and clearing behavior. |
| cl/phase1/forkchoice/forkchoice.go | Stores payload attestation context cache and shared payload validation coordinator in forkchoice. |
| cl/phase1/forkchoice/fork_graph/fork_graph_test.go | Tests anchor state-root caching behavior across cases. |
| cl/phase1/forkchoice/fork_graph/fork_graph_disk.go | Fixes anchor/previous state-root handling for Gloas and skipped-slot nuances. |
| cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go | Writes correct state root for skipped-slot states when dumping to disk. |
| cl/phase1/execution_client/payload_validation_coordinator.go | New: bounds/coalesces distinct/same-key NewPayload calls to an execution client. |
| cl/phase1/execution_client/payload_validation_coordinator_test.go | Tests coordinator concurrency bounds and panic behavior. |
| cl/phase1/execution_client/execution_client_engine.go | Ensures Engine API payload transactions encodes as JSON array (not null). |
| cl/phase1/execution_client/execution_client_engine_test.go | Tests JSON encoding of transactions as an array across forks. |
| cl/das/peer_das.go | Adds explicit Start(ctx) to control when PeerDAS goroutines/subscriptions begin. |
| cl/das/peer_das_start_test.go | Tests that PeerDAS subscribes only after Start and only once. |
| cl/das/mock_services/peer_das_mock.go | Regenerates PeerDAS mock to include Start(ctx). |
Files not reviewed (1)
- cl/das/mock_services/peer_das_mock.go: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
2c715f2 to
b85bb15
Compare
|
@yperbasis I reset this PR to its original three-commit scope so it can receive a focused re-review:
The later downloader, recovery, coordinator, payload-attestation, DA/PeerDAS, persistence, and resolver work—and the review discussion attached to those changes—has moved to stacked draft #23249. The current diff is now 19 files / 1,045 changed lines instead of 60 files / 10,904 changed lines. The retained safety work is limited to this path: remote and pending retries are budgeted, The long status comment above describes the extracted branch and is superseded for this PR. Please review the new small diff when convenient. |
|
@yperbasis I reset this PR to its original three-commit scope so it can receive a focused re-review:
The later downloader, recovery, DA/PeerDAS, persistence/journal, canonical-ingress, and resolver work—and the review discussion attached to those changes—has moved to stacked draft #23249. The payload-attestation OOM cluster remains here because it is required for stable Gloas devnet operation. The current diff is now 26 files / 1,855 changed lines instead of 60 files / 10,904 changed lines. The retained safety work is limited to this path: remote and pending retries are budgeted, The long status comment above describes the extracted branch and is superseded for this PR. Please review the new small diff when convenient. |
|
@yperbasis @AskAlexSharov Review-feedback disposition after the scope reset: Retained in this PR and addressed
Removed from this PR, not dismissedThe backward/forward downloader rewrite, lookahead trust and EMPTY/FULL inference, HTTP/P2P recovery budgets, skipped-envelope persistence, live envelope resolver, envelope journal/prune/startup recovery, canonical-ingress hardening, and observer aggregate behavior are no longer in #22683. They are preserved in stacked draft #23249, which is intentionally not ready to merge. The related High/Medium feedback remains follow-up work there rather than being claimed as fixed by this PR. This includes the inline findings about local failures banning peers, nil-engine stage blocking, repeated full-state copies, prune error/scan bounds, envelope-before-block handling, Gloas EMPTY skipping, stale lookahead trust, one-block forward progress, resolver hot-path copying, startup scratch artifacts, repeated SSZ hashing, and observer aggregate cost. I replied to each thread with its exact disposition and resolved it only because the referenced code is absent from the current diff. Low/nit disposition
Current core diff: 31 files, +1756/-203 (1,959 changed lines). The exact head passed the PeerDAS regression and affected packages, lint twice, and |
|
Scope correction: the small PeerDAS startup-order fix is now restored in #22683 as commit The devnet warning was reproducible: The core diff remains close to the requested size at 31 files / 1,959 changed lines. The larger DA retry/resolver lifecycle remains excluded: |
yperbasis
left a comment
There was a problem hiding this comment.
Combined review: deep automated review + Codex findings + the open Copilot thread, all verified against head fc350dbe.
High — fix before merge
-
Anchor state-root selection breaks restarts —
cl/phase1/forkchoice/fork_graph/fork_graph_disk.go:154. Two sides of one defect:- Legacy datadir (pre-PR finalized state: same-slot header root filled, no sidecar):
RestoreFinalizedStateRootreturns nil (finalized_state_root.go:49errors only whenheader.Root == 0),PeekPreviousStateRoot() == 0, so the constructor recomputesHashSSZ()over the Root-filled state → non-canonical root → the firsttransitionSlotpoisonsstate_rootsand every child block fails state-root verification. Reproduced independently three times (two probe tests, one Codex repro). - New-format file at a block slot (
header.Root == 0, sidecar present): theanchorHeader.Root == 0disjunct discards the restored sidecar root and recomputes, whileanchorRoot(line 147) was already derived from that same sidecar root viaBlockRoot()'spreviousStateRootpreference — two sources of truth for the anchor, plus one wasted full-state merkleization per startup. This is the open Copilot thread (#22683 (comment)).
One rewrite fixes both: trust a non-zero
PeekPreviousStateRoot(); when absent, trust a non-zero same-slotanchorHeader.Root(legacy artifact); otherwiseHashSSZ(). Note the 'legacy block slot' test pre-sets the cached root, which real legacy files never have — add a case without it. - Legacy datadir (pre-PR finalized state: same-slot header root filled, no sidecar):
-
Admission-wait expiry persists envelopes the EL never executed —
cl/phase1/forkchoice/on_execution_payload.go:294.<-ctx.Done()while waiting for the cap-1 admission slot returnsPayloadStatusNone, which is mapped toerrELBehind, andapplyEnvelopeCoordinatedstill processes, dumps, and queues the envelope →HasEnvelope = truewith zero EL contact. Under the shared 2s retry budget (chain_tip_sync.go:697) one slow NewPayload makes this systematic, and PTC duty then answersPAYLOAD_PRESENToffHasEnvelope(epbs.go:219). Distinguish admission-wait cancellation (requeue, do not persist) from a real EL answer. -
Local ctx errors ban innocent peers —
cl/phase1/network/services/payload_attestation_service.go:234.beginValidation's duplicate-key wait and the validation-context build waits (payload_attestation_validation.go:100,:129) return rawctx.Err(); the gossip manager REJECTs and bans the relaying peer for any non-ignore error (gossip_manager.go:185-196). Codex reproducedcontext deadline exceededescaping this path. Map local cancellation/deadline errors toservices.ErrIgnore. -
Failed pending envelopes are immortal —
cl/phase1/forkchoice/on_block.go:548. On apply error the entry is neither dequeued nor expired (base removed it before its single attempt); block-not-found re-Adds and the Contains+Get touches promote dead entries to MRU in the 128-cap LRU with no TTL → wasted GetState/BLS/EL work plus warn spam every cycle, forever. Alsoexecution_payload_service.go:140-155queues unknown-root envelopes before any builder-signature check, keyed by root, so junk can overwrite a genuine queued envelope. Dequeue-or-backoff on hard failure, add an expiry, verify the builder signature before queueing.
Medium — degraded-mode hazards
-
SupportInsertiongate dropped in forward sync —cl/phase1/stages/forward_sync.go:213. Local ELs now pay a serialized per-envelopeNewPayload(InsertBlockper envelope) while the same blocks are batch-inserted two lines below, plus a duplicate BLS verify per envelope. A spurious EL INVALID on a historical envelope wedges sync: children hard-fail on the cached Invalidated status and each retry bans a peer. Restore the distinction, or skip NewPayload when insertion is available. -
Admission-channel bypasses and shared budget —
cl/phase1/stages/chain_tip_sync.go:518andforward_sync.go:652callNewPayloaddirectly, so a retry can validate the same payload concurrently with a gossip validation — the duplication the admission channel exists to prevent. Gossip validation meanwhile waits in the admission with the deadline-free pubsub ctx, andRetryPendingExecutionPayloadEnvelopescan consume the whole 2s budget, starving the other two retry stages. Route the direct sites through the admission helper; give the gossip wait a short timeout mapping to ErrIgnore + requeue; per-stage budgets.
Low — cleanups
cl/phase1/stages/forkchoice.go:309: sha256 over the full snappy-encoded state runs twice per save and twice per load — compute the digest once and pass it through.on_block.go:558: the completedByAnother equality check does a disk read plusHashSSZof both envelopes; comparingeth2Roots.Get(blockRoot)againstpending.Message.Payload.BlockHash(or the two signatures) is O(1).- Duplication: the post-yield revalidation block is copy-pasted between
applyEnvelopeCoordinated(:443-462) andapplyLocalSelfBuildEnvelopeCoordinated(:659-675);replaceDurableFile+syncDirectoryis the codebase's third atomic-write/fsync-dir copy (db/state/commitment_convert.go:963,common/dbg/experiments.go:337);RestoreFinalizedStateRootre-derives the record formatEncodeFinalizedStateRootdefines — verify by re-encoding instead. on_block.go:523: theContains+ discarded-resultGetpairs read as dead code but are the retry-rotation mechanism (LRU recency touch); an explicit requeue structure or a named helper would prevent someone "fixing" them toPeek.cl/phase1/forkchoice/forkchoice.go:1101:DrainPendingELPayloads(unlimited) has only test callers and itslen > limitbranch is unreachable; point the tests atDrainPendingELPayloadsLimitand delete it.cl/phase1/forkchoice/CLAUDE.md:43: still mapson_execution_payload.gotoapplyEnvelopeLocked, renamed toapplyEnvelopeCoordinatedin this PR.
|
Review follow-up for I went through the latest feedback and kept the changes within the Gloas payload validation/recovery scope. The actionable findings are addressed as follows:
Validation completed:
I intentionally did not fold unrelated pre-existing behavior or broad refactors into this update. |
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for two correctness issues:
- The selected Gloas head is not requested when its envelope is missing. recoverMissingEnvelopes scans only parents, and pollForEnvelope only checks local storage. If gossip misses the head envelope, FCU keeps the inherited execution hash until a child arrives. This is covered by the existing open thread: #22683 (comment)
- The payload-attestation validation bound can ignore a valid competing message before any candidate validates. See the inline comment.
The focused affected-package tests pass, but neither scenario is covered.
|
Addressed the two latest review findings in c005feb. Selected-head envelope recovery:
Payload-attestation first-valid handling:
Deliberate scope boundary: hardening an internal requester that permanently violates context cancellation would require managed worker ownership, and strict cross-validator scheduling fairness would require pubsub/per-peer scheduler changes. The production requester honors context; validation work is bounded by the existing 512 admission plus libp2p validation queue/throttle. Those broader redesigns are not included here. Validation:
|
|
Pushed What changed:
TDD / validation:
Two independent adversarial/lifecycle reviews converged on the changed recovery surface. GitHub checks are restarting from the new head. |
Summary
Fix the original standalone-Caplin Gloas checkpoint-sync path when using an external execution client.
null.NewPayload, and refresh identity before committing the result.Scope
This PR has been reset to its original three behaviors plus the minimal lifecycle correction required to make external-EL validation safe, the narrow pending-envelope terminal rule required to stop finalized side-branch retries, the payload-attestation OOM fix, and the PeerDAS startup-order fix required by the Gloas devnet. The later downloader recovery, broader envelope durability/canonical-ingress lifecycle work, and live resolver work were removed from this diff and preserved in draft #23249.
The reset reduces the cumulative diff from 60 files / 10,904 changed lines to 31 files / 1,959 changed lines before the focused stale-envelope regression coverage. The additional files are safety dependencies of the original external-EL/checkpoint path plus bounded payload-attestation validation and ordered PeerDAS startup needed for stable devnet operation, not the later downloader/resolver feature set.
The pending-envelope rule uses the Gloas finalized epoch start as its lower bound. A next-slot envelope is retained only within
MAXIMUM_GOSSIP_CLOCK_DISPARITY; farther future slots are rejected before slot-time conversion, avoiding attacker-controlled arithmetic aliases. Queue insertion remains atomic with the missing-block observation, while retries do not overwrite concurrent replacements. Gossip and local-self-build origins use the same lifecycle rule, and nil index-repair sentinels remain unchanged.Validation
TestRetryPendingExecutionPayloadEnvelopesDropsMissingBlockOlderThanFinalityfailed before the production change because the stale entry remained queued, then passed after the terminal rule was implemented.go test ./cl/phase1/forkchoice -count=1go test ./cl/phase1/network/services -count=1go test ./cl/phase1/stages -count=1go test -race ./cl/phase1/forkchoice -run 'TestRetryPendingExecutionPayloadEnvelopes(KeepsConcurrentReplacement|DropsMissingBlockOlderThanFinality|DropsFarFutureSlot)' -count=1go test -count=1 -timeout=15m ./cl/das ./cl/spectest/consensus_tests ./cmd/caplin/caplin1 ./cl/phase1/core/checkpoint_sync ./cl/phase1/execution_client ./cl/phase1/forkchoice ./cl/phase1/forkchoice/fork_graph ./cl/phase1/network/services ./cl/phase1/stages ./execution/engineapi/engine_typesgo test -race -count=1 -timeout=20m ./cl/phase1/core/checkpoint_sync ./cl/phase1/execution_client ./cl/phase1/forkchoice ./cl/phase1/forkchoice/fork_graph ./cl/phase1/network/services ./cl/phase1/stages ./execution/engineapi/engine_typesgloas-caplin-mixedandgloas-three-cl-mixedpassed usingglamsterdam-devnet-7CL/VC images.make lintrepeatedly until cleanmake erigon integrationgit diff --checkThis history rewrite is intentional so the original fix can be reviewed and merged independently.